{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# 分块优化\n",
    "优化分块的目的：\n",
    "* 使 embedding 后的向量提取到完整的语义信息。一般 embedding model 的 max token 即最大可 embedding 句子长度通常为512至1024 token，过长的分块或分块中有多个语义的句子在 embedding 时很难从中提取完整的语义信息。优化分块后理想的文本块中语义是单一的，长度是合理的，很容易通过 embedding 提取出完整且的语义信息。  \n",
    "token:不同的 embedding model 有不同的计算方式，1 token 约对应0.7个单词或1.5个汉字。\n",
    "* 优化检索效果，减少大模型参考的上下文长度，减少 token 使用。当向大模型应用提问时，问题先会被 embedding 再通过余弦距离等检索算法获取前 k 个与问题相似性最高的文本块，最终问题与文本块返回大模型进行处理。分块优化后我们每次检索返回的文本块会更加准确，原本包含在前 k 个文本块的答案，优化后答案会在前 k-n 个文本块里，这样一来在检索时返回比原来更少的文本块即可达到与之前相同的效果，这样一来我们减少了大模型参考的上下文长度，从而减少了 token 使用。\n",
    "## 一.基于策略分块\n",
    "我们选用 Datawhale 一些经典开源课程作为示例，具体包括：\n",
    "* [《机器学习公式详解》PDF版本](https://github.com/datawhalechina/pumpkin-book/releases)\n",
    "* [《面向开发者的LLM入门教程、第一部分Prompt Engineering》md版本](https://github.com/datawhalechina/llm-cookbook)  "
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 1,
   "metadata": {},
   "outputs": [],
   "source": [
    "from langchain_community.document_loaders.pdf import PyMuPDFLoader\n",
    "\n",
    "# 创建一个 PyMuPDFLoader Class 实例，输入为待加载的 pdf 文档路径\n",
    "loader = PyMuPDFLoader(\"../../../data_base/knowledge_db/pumkin_book/pumpkin_book.pdf\")\n",
    "\n",
    "# 调用 PyMuPDFLoader Class 的函数 load 对 pdf 文件进行加载\n",
    "pdf_pages = loader.load()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "pdf文件第一页信息较少，为了方便演示，我们只对pdf文件第二页进行分块。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 2,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "第二页内容长度：1331\n",
      "----------\n",
      "第二页部分内容：\n",
      "前言\n",
      "“周志华老师的《机器学习》\n",
      "（西瓜书）是机器学习领域的经典入门教材之一，周老师为了使尽可能多的读\n",
      "者通过西瓜书对机器学习有所了解, 所以在书中对部分公式的推导细节没有详述，但是这对那些想深究公式推\n",
      "导细节的读者来说可能“不太友好”\n",
      "，本书旨在对西瓜书里比较难理解的公式加以解析，以及对部分公式补充\n",
      "具体的推导细节。\n",
      "”\n",
      "读到这里，大家可能会疑问为啥前面这段话加了引号，因为这只是我们最初的遐想，后来我们了解到，周\n",
      "老师之所以省去这些推导细节的真实原因是，他本尊认为“理工科数学基础扎实点的大二下学生应该对西瓜书\n",
      "中的推导细节无困难吧，要点在书里都有了，略去的细节应能脑补或做练习”\n",
      "。所以...... 本南瓜书只能算是我\n",
      "等数学渣渣在自学的时候记下来的笔记，希望能够帮助大家都成为一名合格的“理工科数学基础扎实点的大二\n",
      "下学生”\n",
      "。\n",
      "使用说明\n",
      "• 南瓜书的所有内容都是以西瓜书的内容为前置知识进行表述的，所以南瓜书的最佳使用方法是以西瓜书\n",
      "为主线，遇到自己推导不出来或者看不懂的公式时再来查阅南瓜书；\n",
      "• 对于初学机器学习的小白，西瓜书第1 章和第2 章的公式强烈不建议深究，简单过一下即可，等\n"
     ]
    }
   ],
   "source": [
    "pdf_page_1 = pdf_pages[1].page_content\n",
    "\n",
    "print(f'第二页内容长度：{len(pdf_page_1)}',\n",
    "      f'第二页部分内容：\\n{pdf_page_1[:500]}',\n",
    "      sep='\\n----------\\n')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 1.定长分块\n",
    "![FixedSizeChunk](./figures/FixedSizedChunk.png)  \n",
    "定长分块是最简单最直接的分块方式，只需要`chunk_size`文本块大小跟`chunk_overlap`文本块重叠部分大小即可完成分块。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 3,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "text长度为：102\n",
      "分块后结果为：\n",
      "“周志华老师的《机器学习》（西瓜书）是机器学习领域的经典入门\n",
      "的经典入门教材之一，周老师为了使尽可能多的读者通过西瓜书对机\n",
      "西瓜书对机器学习有所了解， 所以在书中对部分公式的推导细节没\n",
      "推导细节没有详述，但是这对那些想深究公式推导细节的读者\n"
     ]
    }
   ],
   "source": [
    "def split_text(chunk_sixe: int, chunk_overlap: int, text: str) -> list[str]:\n",
    "    docs = []\n",
    "    # 每块起始点为n*(chunk_sixe - chunk_overlap),结束点为起始点位置 + chunk_sixe\n",
    "    for i in range(0, len(text), chunk_sixe - chunk_overlap):\n",
    "            if i + chunk_sixe > len(text): \n",
    "                 docs.append(text[i:])\n",
    "                 return docs\n",
    "            docs.append(text[i:i + chunk_sixe])\n",
    "    return docs\n",
    "\n",
    "text = '“周志华老师的《机器学习》（西瓜书）是机器学习领域的经典入门教材之一，周老师为了使尽可能多的读者通过西瓜书对机器学习有所了解， 所以在书中对部分公式的推导细节没有详述，但是这对那些想深究公式推导细节的读者'\n",
    "print(f'text长度为：{len(text)}')\n",
    "docs = split_text(30, 5, text)\n",
    "print(f'分块后结果为：')\n",
    "for doc in docs: print(doc)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 2.字符分块\n",
    "定长分块方式太过死板，在绝大多数场景都很难科学地将文本分块，我们可以通过使用`langchain`中的字符文本分块器`CharacterTextSplitter`来实现灵活度更高的分块方式。\n",
    "* `CharacterTextSplitter`会在前`chunk_size`内搜寻我们给定的分隔符号并进行分块。\n",
    "* 我们可以通过给定`chunk_size`文本块大小、`chunk_overlap`文本块重叠部分及`separator`分割符号来实例化`CharacterTextSplitter`。\n",
    "* 当返回的子字符串长度超过给定的`chunk_size`时，会报`Created a chunk of size {子字符串长度}, which is longer than the specified {chunk_size}`警告。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 4,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "Created a chunk of size 34, which is longer than the specified 30\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "分块后结果为：\n",
      "“周志华老师的《机器学习》（西瓜书）是机器学习领域的经典入门教材之一\n",
      "周老师为了使尽可能多的读者通过西瓜书对机器学习有所了解\n",
      "所以在书中对部分公式的推导细节没有详述\n",
      "但是这对那些想深究公式推导细节的读者\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.character import CharacterTextSplitter\n",
    "\n",
    "text_splitter = CharacterTextSplitter(chunk_size=30, chunk_overlap=0, separator='，', keep_separator=False)\n",
    "\n",
    "split_docs = text_splitter.split_text(text)\n",
    "print(f'分块后结果为：')\n",
    "for doc in split_docs: print(doc)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 3.递归字符分块\n",
    "当我们要分块的文本结构比较复杂时（比如包含多个段落，标点符号出现频繁的文本）只支持单符号分块的`CharacterTextSplitter`显然很难完成分块，这时候我们可以选择支持按多个分割符进行顺序分割的`RecursiveCharacterTextSplitter`。"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "`langchain`的默认分隔符为：\n",
    "* '\\n\\n'：两个换行符\n",
    "* '\\n'：一个换行符\n",
    "* ' '： 空格\n",
    "* ''：任意字符  \n",
    "\n",
    "当使用`RecursiveCharacterTextSplitter`切分文本时，`RecursiveCharacterTextSplitter`会以分隔符的顺序为优先级递归建立分块，即先看前`chunk_size`的字符串中有没有第一优先级的分隔符，有的话进行分块，没有的话查找是否有下一优先级的分隔符，像这样一直寻找直到找到符合要求的分隔符进行分块。  \n",
    "接下来我们将长度为9的字符串`'原来RAG如此简单'`为单位，以每个字符串中间插入一个空格，每两个字符串中间插入一换行符，每四个字符串中间插入两个换行符来了解`RecursiveCharacterTextSplitter`的优先级。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 5,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "以chunk_size为10分块后结果为：\n",
      "原来RAG如此简单\n",
      "------\n",
      "原来RAG如此简单\n",
      "\n",
      "以chunk_size为20分块后结果为：\n",
      "原来RAG如此简单 原来RAG如此简单\n",
      "------\n",
      "原来RAG如此简单 原来RAG如此简单\n",
      "\n",
      "以chunk_size为41分块后结果为：\n",
      "原来RAG如此简单 原来RAG如此简单\n",
      "原来RAG如此简单 原来RAG如此简单\n",
      "------\n",
      "原来RAG如此简单 原来RAG如此简单\n",
      "原来RAG如此简单 原来RAG如此简单\n",
      "\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.character import RecursiveCharacterTextSplitter\n",
    "text = \"\"\"原来RAG如此简单 原来RAG如此简单\n",
    "原来RAG如此简单 原来RAG如此简单\n",
    "\n",
    "原来RAG如此简单 原来RAG如此简单\n",
    "原来RAG如此简单 原来RAG如此简单\"\"\"\n",
    "# 分别以10、20、41进行分块并打印前两个分块。\n",
    "# 因为在'原来RAG如此简单'中插入了字符，所以10、20、41分别是前1、2、4个'原来RAG如此简单'的长度\n",
    "chunk_size_list = [10, 20, 41]\n",
    "for chunk_size in chunk_size_list:\n",
    "    text_splitter = RecursiveCharacterTextSplitter(chunk_size=chunk_size, chunk_overlap=0)\n",
    "    split_docs = text_splitter.split_text(text)\n",
    "    print(f'以chunk_size为{chunk_size}分块后结果为：')\n",
    "    print(split_docs[0], split_docs[1], sep='\\n------\\n')\n",
    "    print()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "当`separators`只有一个符号时效果是跟`CharacterTextSplitter`一样的。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 6,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "分块后结果为：\n",
      "“周志华老师的《机器学习》（西瓜书）是机器学习领域的经典入门教材之一\n",
      "周老师为了使尽可能多的读者通过西瓜书对机器学习有所了解\n",
      "所以在书中对部分公式的推导细节没有详述\n",
      "但是这对那些想深究公式推导细节的读者\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.character import RecursiveCharacterTextSplitter\n",
    "\n",
    "text = '“周志华老师的《机器学习》（西瓜书）是机器学习领域的经典入门教材之一，周老师为了使尽可能多的读者通过西瓜书对机器学习有所了解， 所以在书中对部分公式的推导细节没有详述，但是这对那些想深究公式推导细节的读者'\n",
    "\n",
    "text_splitter = RecursiveCharacterTextSplitter(chunk_size=30, chunk_overlap=0, separators='，', keep_separator=False)\n",
    "split_docs = text_splitter.split_text(text)\n",
    "print(f'分块后结果为：')\n",
    "for doc in split_docs: print(doc)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 4.特定格式分块\n",
    "当我们要将md、html等特定格式文件分块时我们可以使用`MarkdownTextSplitter`、`HTMLHeaderTextSplitter`等对应分块器实现。上述分块器继承了`RecursiveCharacterTextSplitter`类，与该类不同的是将`separators`替换为各个类型文件及语言块的前缀符号。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 7,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "'\\n#{1,6} '\n",
      "'```\\n'\n",
      "'\\n\\\\*\\\\*\\\\*+\\n'\n",
      "'\\n---+\\n'\n",
      "'\\n___+\\n'\n",
      "'\\n\\n'\n",
      "'\\n'\n",
      "' '\n",
      "''\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.markdown import MarkdownTextSplitter\n",
    "\n",
    "md_splitter = MarkdownTextSplitter()\n",
    "md_separators = md_splitter._separators\n",
    "\n",
    "for separator in md_separators:\n",
    "    print(r'{}'.format(repr(separator)))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 8,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "### 1.1 使用分隔符清晰地表示输入的不同部分\n",
      "\n",
      "在编写 Prompt 时，我们可以使用各种标点符号作为“分隔符”，将不同的文本部分区分开来。\n",
      "\n",
      "分隔符就像是 Prompt 中的墙，将不同的指令、上下文、输入隔开，避免意外的混淆。你可以选择用 ` ```，\"\"\"，< >，<tag> </tag>，: ` 等做分隔符，只要能明确起到隔断作用即可。\n",
      "\n",
      "使用分隔符尤其重要的是可以防止 **提示词注入（Prompt Rejection）**。什么是提示词注入？就是用户输入的文本可能包含与你的预设 Prompt 相冲突的内容，如果不加分隔，这些输入就可能“注入”并操纵语言模型，导致模型产生毫无关联的乱七八糟的输出。\n",
      "\n",
      "在以下的例子中，我们给出一段话并要求 GPT 进行总结，在该示例中我们使用 ``` 来作为分隔符。\n",
      "\n",
      "\n",
      "```python\n",
      "from tool import get_completion\n",
      "\n",
      "text = f\"\"\"\n",
      "您应该提供尽可能清晰、具体的指示，以表达您希望模型执行的任务。\\\n",
      "这将引导模型朝向所需的输出，并降低收到无关或不正确响应的可能性。\\\n",
      "不要将写清晰的提示词与写简短的提示词混淆。\\\n",
      "在许多情况下，更长的提示词可以为模型提供更多的清晰度和上下文信息，从而导致更详细和相关的输出。\n",
      "\"\"\"\n",
      "# 需要总结的文本内容\n",
      "prompt = f\"\"\"\n",
      "把用三个反引号括起来的文本总结成一句话。\n",
      "```{text}```\n",
      "\"\"\"\n",
      "# 指令内容，使用 ``` 来分隔指令和待总结的内容\n",
      "response = get_completion(prompt)\n",
      "print(response)\n",
      "```\n",
      "\n",
      "    为了获得所需的输出，您应该提供清晰、具体的指示，避免与简短的提示词混淆，并使用更长的提示词来提供更多的清晰度和上下文信息。\n"
     ]
    }
   ],
   "source": [
    "# 我们选择 Prompt Engineering 中结构较复杂的 2. 提示原则 Guidelines.md 作为示例\n",
    "with open('../../../data_base/knowledge_db/prompt_engineering/2. 提示原则 Guidelines.md', 'r') as file:\n",
    "    md_data = file.read()\n",
    "\n",
    "print(md_data[892:1662])"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "可以看到长度为892-1662之间的字符结构比较复杂，既包括三级标题也有代码块及不少的换行符，因此我们选用这部分数据进行分块。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 9,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "### 1.1 使用分隔符清晰地表示输入的不同部分\n",
      "\n",
      "在编写 Prompt 时，我们可以使用各种标点符号作为“分隔符”，将不同的文本部分区分开来。\n",
      "\n",
      "分隔符就像是 Prompt 中的墙，将不同的指令、上下文、输入隔开，避免意外的混淆。你可以选择用 ` ```，\"\"\"，< >，<tag> </tag>，: ` 等做分隔符，只要能明确起到隔断作用即可。\n",
      "---------\n",
      "使用分隔符尤其重要的是可以防止 **提示词注入（Prompt Rejection）**。什么是提示词注入？就是用户输入的文本可能包含与你的预设 Prompt 相冲突的内容，如果不加分隔，这些输入就可能“注入”并操纵语言模型，导致模型产生毫无关联的乱七八糟的输出。\n",
      "\n",
      "在以下的例子中，我们给出一段话并要求 GPT 进行总结，在该示例中我们使用 ``` 来作为分隔符。\n",
      "\n",
      "\n",
      "```python\n",
      "from tool import get_completion\n",
      "---------\n",
      "```python\n",
      "from tool import get_completion\n",
      "\n",
      "text = f\"\"\"\n",
      "您应该提供尽可能清晰、具体的指示，以表达您希望模型执行的任务。\\\n",
      "这将引导模型朝向所需的输出，并降低收到无关或不正确响应的可能性。\\\n",
      "不要将写清晰的提示词与写简短的提示词混淆。\\\n",
      "在许多情况下，更长的提示词可以为模型提供更多的清晰度和上下文信息，从而导致更详细和相关的输出。\n",
      "\"\"\"\n",
      "# 需要总结的文本内容\n",
      "prompt = f\"\"\"\n",
      "把用三个反引号括起来的文本总结成一句话。\n",
      "```{text}\n",
      "---------\n",
      "\"\"\"\n",
      "# 指令内容，使用 ``` 来分隔指令和待总结的内容\n",
      "response = get_completion(prompt)\n",
      "print(response)\n",
      "```\n",
      "\n",
      "    为了获得所需的输出，您应该提供清晰、具体的指示，避免与简短的提示词混淆，并使用更长的提示词来提供更多的清晰度和上下文信息。\n",
      "---------\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.markdown import MarkdownTextSplitter\n",
    "\n",
    "md_splitter = MarkdownTextSplitter(chunk_size=300, chunk_overlap=50, keep_separator=False)\n",
    "\n",
    "split_docs = md_splitter.split_text(md_data[892:1662])\n",
    "\n",
    "for doc in split_docs: print(doc, end='\\n---------\\n')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### 5.按最大token数分块\n",
    "\n",
    "前边我们从文本串长度、符号等不同方面考虑如何将文本合理分块，我们分块的`chunk_size`都是固定的，文本块只能小于或者等于`chunk_size`，而文本块最终是要被 `embedding model` 向量化存储到向量数据库，但 `embedding model` 向量化文本的能力是与字符串长度不同概念的`token`数量，因此我们通过上述策略得到的分块绝大部分都会小于或大于 `embedding model` 的 `max tokens` 数量，很难卡在 `max tokens` 上。针对这个问题`SentenceTransformersTokenTextSplitter`便应运而生，它可以将待分块文本按所使用 `embedding model` 的`max tokens`进行分块。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 10,
   "metadata": {},
   "outputs": [
    {
     "name": "stderr",
     "output_type": "stream",
     "text": [
      "/Users/lta/anaconda3/envs/llm_universe_2.x/lib/python3.10/site-packages/huggingface_hub/file_download.py:1132: FutureWarning: `resume_download` is deprecated and will be removed in version 1.0.0. Downloads always resume when possible. If you want to force a new download, use `force_download=True`.\n",
      "  warnings.warn(\n"
     ]
    },
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "第1个文本块长度为：1032，token共计514\n",
      "第2个文本块长度为：1111，token共计514\n",
      "第3个文本块长度为：1146，token共计514\n",
      "第4个文本块长度为：1146，token共计514\n",
      "第5个文本块长度为：1099，token共计514\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.sentence_transformers import SentenceTransformersTokenTextSplitter\n",
    "\n",
    "# 因为我们需要embedding的数据中文居多，所以我们不使用默认的\"sentence-transformers/all-mpnet-base-v2\"而将model_name指定为\"moka-ai/m3e-base\"\n",
    "token_splitter = SentenceTransformersTokenTextSplitter(chunk_overlap=0, model_name=\"moka-ai/m3e-base\")\n",
    "\n",
    "split_docs = token_splitter.split_text(md_data[:6000])\n",
    "for i in range(5): print(f'第{i + 1}个文本块长度为：{len(split_docs[i])}，token共计{token_splitter.count_tokens(text=split_docs[i])}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "数据在切分时分词器会先将数据中的词（这里的‘词’是分词表`tokenizer`中的词而不是词语的词）编码为`token`的索引，再将索引解码为分词表中对应的词，最终返回词与词之间有空格的文本块且没在分词表中的词会解码为`[UNK]`。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 11,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "切分后返回的第一个分块：# 第 二 章 提 示 原 则 如 何 去 使 用 prompt ， 以 充 分 发 挥 llm 的 性 能 ？ 首 先 我 们 需 要 知 道 设 计 prompt 的 原 则 ， 它 们 是 每 \n",
      "分词表中未记录的词被转为[UNK]：用 [UNK] [UNK] [UNK] [UNK] ， \" \" \" ， < > ， < tag > < / tag > ， : [UNK] 等 做 分 隔 符 ，\n"
     ]
    }
   ],
   "source": [
    "print(f'切分后返回的第一个分块：{split_docs[0][:100]}')\n",
    "print(f'分词表中未记录的词被转为[UNK]：{split_docs[1][740:821]}')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 二.语义分块"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "langchain同时也提供了基于语义的分块`SemanticChunker`，该策略保证分割后得到的每个文本块中文本都具有相似的语义。相比之前介绍的分块策略语义分块比较复杂，为了方便大家理解我将分两步进行介绍。\n",
    "\n",
    "1. `SemanticChunker`在分块时首先对文本进行简单分割（以`. ? !`作为分割符），再将每个句子与该句子后`buffer_size`（默认为1）个句子拼接起来并进行向量化，最后计算每个拼接句子与后一个拼接句子的余弦距离。\n",
    "![SemanticChunker_1](./figures/Semantic_Chunker_1.png)  \n",
    "2. `SemanticChunker`根据实例化时用户是否给定文本块数量`number_of_chunks`进行不同的操作求阈值，用户给定`number_of_chunks`时`SemanticChunker`会使用线性插值公式根据`number_of_chunks`计算百分位数再结合距离列表得到组数的阈值，如果没有指定`number_of_chunks``SemanticChunker`会使用预设方法（`breakpoint_threshold_type`默认为`percentile`百分位法即计算数组在某百分位数时的阈值，除此之外还有`standard_deviation`标准差法与`interquartile`四分位距法）计算阈值，最后根据阈值得到切分的句子。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 12,
   "metadata": {},
   "outputs": [],
   "source": [
    "# 我们选择 Prompt Engineering 中语义较丰富的 1. 简介 Introduction.md 作为示例\n",
    "with open('../../../data_base/knowledge_db/prompt_engineering/1. 简介 Introduction.md', 'r') as file:\n",
    "    md_data = file.read()\n",
    "# 因为是中文数据所以将'。' '？' '！'替换为'.' '?' '!'\n",
    "md_data = md_data.replace('。', '.').replace('？', '?').replace('！', '!')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 13,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "第0个doc长度为：1712\n",
      "内容为：# 第一章 简介\n",
      "\n",
      "欢迎来到**面向开发者的提示工程**部分，本部分内容基于**吴恩达老师的《Prompt Engineering for Developer》课程**进行编写.《Prompt Engineering for Developer》课程是由**吴恩达老师**与 OpenAI 技术团队成员 **Isa Fulford** 老师合作授课，Isa 老师曾开发过受欢迎的 ChatGPT 检索插件，并且在教授 LLM （Large Language Model， 大语言模型）技术在产品中的应用方面做出了很大贡献.她还参与编写了教授人们使用 Prompt 的 OpenAI cookbook.我们希望通过本模块的学习，与大家分享使用提示词开发 LLM 应用的最佳实践和技巧. 网络上有许多关于提示词（Prompt， 本教程中将保留该术语）设计的材料，例如《30 prompts everyone has to know》之类的文章，这些文章主要集中在 **ChatGPT 的 Web 界面上**，许多人在使用它执行特定的、通常是一次性的任务.但我们认为，对于开发人员，**大语言模型（LLM） 的更强大功能是能通过 API 接口调用，从而快速构建软件应用程序**.实际上，我们了解到 DeepLearning.AI 的姊妹公司 AI Fund 的团队一直在与许多初创公司合作，将这些技术应用于诸多应用程序上.很兴奋能看到 LLM API 能够让开发人员非常快速地构建应用程序. 在本模块，我们将与读者分享提升大语言模型应用效果的各种技巧和最佳实践.书中内容涵盖广泛，包括软件开发提示词设计、文本总结、推理、转换、扩展以及构建聊天机器人等语言模型典型应用场景.我们衷心希望该课程能激发读者的想象力，开发出更出色的语言模型应用. 随着 LLM 的发展，其大致可以分为两种类型，后续称为**基础 LLM** 和**指令微调（Instruction Tuned）LLM**.**基础LLM**是基于文本训练数据，训练出预测下一个单词能力的模型.其通常通过在互联网和其他来源的大量数据上训练，来确定紧接着出现的最可能的词.例如，如果你以“从前，有一只独角兽”作为 Prompt ，基础 LLM 可能会继续预测“她与独角兽朋友共同生活在一片神奇森林中”.但是，如果你以“法国的首都是什么”为 Prompt ，则基础 LLM 可能会根据互联网上的文章，将回答预测为“法国最大的城市是什么?法国的人口是多少?”，因为互联网上的文章很可能是有关法国国家的问答题目列表. 与基础语言模型不同，**指令微调 LLM** 通过专门的训练，可以更好地理解并遵循指令.举个例子，当询问“法国的首都是什么?”时，这类模型很可能直接回答“法国的首都是巴黎”.指令微调 LLM 的训练通常基于预训练语言模型，先在大规模文本数据上进行**预训练**，掌握语言的基本规律.在此基础上进行进一步的训练与**微调（finetune）**，输入是指令，输出是对这些指令的正确回复.有时还会采用**RLHF（reinforcement learning from human feedback，人类反馈强化学习）**技术，根据人类对模型输出的反馈进一步增强模型遵循指令的能力.通过这种受控的训练过程.指令微调 LLM 可以生成对指令高度敏感、更安全可靠的输出，较少无关和损害性内容.因此.许多实际应用已经转向使用这类大语言模型. 因此，本课程将重点介绍针对指令微调 LLM 的最佳实践，我们也建议您将其用于大多数使用场景.当您使用指令微调 LLM 时，您可以类比为向另一个人提供指令（假设他很聪明但不知道您任务的具体细节）.因此，当 LLM 无法正常工作时，有时是因为指令不够清晰.例如，如果您想问“请为我写一些关于阿兰·图灵( Alan Turing )的东西”，在此基础上清楚表明您希望文本专注于他的科学工作、个人生活、历史角色或其他方面可能会更有帮助.另外您还可以指定回答的语调， 来更加满足您的需求，可选项包括*专业记者写作*，或者*向朋友写的随笔*等.\n",
      "-----------\n",
      "第1个doc长度为：137\n",
      "内容为：如果你将 LLM 视为一名新毕业的大学生，要求他完成这个任务，你甚至可以提前指定他们应该阅读哪些文本片段来写关于阿兰·图灵的文本，这样能够帮助这位新毕业的大学生更好地完成这项任务.本书的下一章将详细阐释提示词设计的两个关键原则：**清晰明确**和**给予充足思考时间**. \n",
      "-----------\n"
     ]
    }
   ],
   "source": [
    "from langchain_experimental.text_splitter import SemanticChunker\n",
    "from langchain_community.embeddings.huggingface import HuggingFaceEmbeddings\n",
    "\n",
    "embedding = HuggingFaceEmbeddings(model_name=\"moka-ai/m3e-base\")\n",
    "sem_splitter = SemanticChunker(embeddings=embedding)\n",
    "docs = sem_splitter.split_text(md_data)\n",
    "for i in range(len(docs)): print(f'第{i}个doc长度为：{len(docs[i])}\\n内容为：{docs[i]}', end='\\n-----------\\n')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "我们可以看到`SemanticChunker`将文本分成了两个文本块且第一个文本块远远超过了m3e的范围即512的 Max Tokens，如下方运行结果所示这导致向量模型在向量化文本时会丢掉超出范围的语义信息。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 14,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "emb_1与emb_3前 5 个元素逐位相减的结果为：\n",
      "doc_1、doc_3完全相同\n",
      "doc与长度为1837的doc_1的余弦距离为：0.09287377769976846\n",
      "doc与长度为251的doc_2的余弦距离为：0.06218732374287228\n",
      "doc与长度为1000的doc_3的余弦距离为：0.09287377769976846\n"
     ]
    }
   ],
   "source": [
    "from langchain_community.utils.math import cosine_similarity\n",
    "\n",
    "doc = '欢迎来到**面向开发者的提示工程**部分，本部分内容基于**吴恩达老师的《Prompt Engineering for Developer》课程**进行编写.'\n",
    "doc_1 = '欢迎来到**面向开发者的提示工程**部分，本部分内容基于**吴恩达老师的《Prompt Engineering for Developer》课程**进行编写.《Prompt Engineering for Developer》课程是由**吴恩达老师**与 OpenAI 技术团队成员 **Isa Fulford** 老师合作授课，Isa 老师曾开发过受欢迎的 ChatGPT 检索插件，并且在教授 LLM （Large Language Model， 大语言模型）技术在产品中的应用方面做出了很大贡献.她还参与编写了教授人们使用 Prompt 的 OpenAI cookbook.我们希望通过本模块的学习，与大家分享使用提示词开发 LLM 应用的最佳实践和技巧. 网络上有许多关于提示词（Prompt， 本教程中将保留该术语）设计的材料，例如《30 prompts everyone has to know》之类的文章，这些文章主要集中在 **ChatGPT 的 Web 界面上**，许多人在使用它执行特定的、通常是一次性的任务.但我们认为，对于开发人员，**大语言模型（LLM） 的更强大功能是能通过 API 接口调用，从而快速构建软件应用程序**.实际上，我们了解到 DeepLearning.AI 的姊妹公司 AI Fund 的团队一直在与许多初创公司合作，将这些技术应用于诸多应用程序上.很兴奋能看到 LLM API 能够让开发人员非常快速地构建应用程序. 在本模块，我们将与读者分享提升大语言模型应用效果的各种技巧和最佳实践.书中内容涵盖广泛，包括软件开发提示词设计、文本总结、推理、转换、扩展以及构建聊天机器人等语言模型典型应用场景.我们衷心希望该课程能激发读者的想象力，开发出更出色的语言模型应用. 随着 LLM 的发展，其大致可以分为两种类型，后续称为**基础 LLM** 和**指令微调（Instruction Tuned）LLM**.**基础LLM**是基于文本训练数据，训练出预测下一个单词能力的模型.其通常通过在互联网和其他来源的大量数据上训练，来确定紧接着出现的最可能的词.例如，如果你以“从前，有一只独角兽”作为 Prompt ，基础 LLM 可能会继续预测“她与独角兽朋友共同生活在一片神奇森林中”.但是，如果你以“法国的首都是什么”为 Prompt ，则基础 LLM 可能会根据互联网上的文章，将回答预测为“法国最大的城市是什么?法国的人口是多少?”，因为互联网上的文章很可能是有关法国国家的问答题目列表. 与基础语言模型不同，**指令微调 LLM** 通过专门的训练，可以更好地理解并遵循指令.举个例子，当询问“法国的首都是什么?”时，这类模型很可能直接回答“法国的首都是巴黎”.指令微调 LLM 的训练通常基于预训练语言模型，先在大规模文本数据上进行**预训练**，掌握语言的基本规律.在此基础上进行进一步的训练与**微调（finetune）**，输入是指令，输出是对这些指令的正确回复.有时还会采用**RLHF（reinforcement learning from human feedback，人类反馈强化学习）**技术，根据人类对模型输出的反馈进一步增强模型遵循指令的能力.通过这种受控的训练过程.指令微调 LLM 可以生成对指令高度敏感、更安全可靠的输出，较少无关和损害性内容.因此.许多实际应用已经转向使用这类大语言模型. 因此，本课程将重点介绍针对指令微调 LLM 的最佳实践，我们也建议您将其用于大多数使用场景.当您使用指令微调 LLM 时，您可以类比为向另一个人提供指令（假设他很聪明但不知道您任务的具体细节）.因此，当 LLM 无法正常工作时，有时是因为指令不够清晰.例如，如果您想问“请为我写一些关于阿兰·图灵( Alan Turing )的东西”，在此基础上清楚表明您希望文本专注于他的科学工作、个人生活、历史角色或其他方面可能会更有帮助.另外您还可以指定回答的语调，来更加满足您的需求，可选项包括*专业记者写作*，或者*向朋友写的随笔*等.如果你将 LLM 视为一名新毕业的大学生，要求他完成这个任务，你甚至可以提前指定他们应该阅读哪些文本片段来写关于阿兰·图灵的文本，这样能够帮助这位新毕业的大学生更好地完成这项任务.本书的下一章将详细阐释提示词设计的两个关键原则：**清晰明确**和**给予充足思考时间**.'\n",
    "doc_2 = doc_1[:251]\n",
    "doc_3 = doc_1[:1000]\n",
    "\n",
    "emb = embedding.embed_query(doc)\n",
    "emb_1 = embedding.embed_query(doc_1)\n",
    "emb_2 = embedding.embed_query(doc_2)\n",
    "emb_3 = embedding.embed_query(doc_3)\n",
    "# 向量模型在向量化文本时只会向量化Max Tokens范围内的文本\n",
    "# 因此对于1712及1000字符长度的doc_1、doc_3返回的向量是一模一样的\n",
    "print('emb_1与emb_3前 5 个元素逐位相减的结果为：')\n",
    "if emb_1 == emb_3:\n",
    "    print(\"doc_1、doc_3完全相同\")\n",
    "else:\n",
    "    print(\"doc_1、doc_3不相同\")\n",
    "\n",
    "print(f'doc与长度为{len(doc_1)}的doc_1的余弦距离为：{1 - cosine_similarity([emb], [emb_1])[0][0]}')\n",
    "print(f'doc与长度为{len(doc_2)}的doc_2的余弦距离为：{1 - cosine_similarity([emb], [emb_2])[0][0]}')\n",
    "print(f'doc与长度为{len(doc_3)}的doc_3的余弦距离为：{1 - cosine_similarity([emb], [emb_3])[0][0]}')\n"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "我们可以配合分块策略来解决这一问题。"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": 15,
   "metadata": {},
   "outputs": [
    {
     "name": "stdout",
     "output_type": "stream",
     "text": [
      "第0个doc长度为：474\n",
      "-----------\n",
      "第1个doc长度为：499\n",
      "-----------\n",
      "第2个doc长度为：469\n",
      "-----------\n",
      "第3个doc长度为：265\n",
      "-----------\n",
      "第4个doc长度为：136\n",
      "-----------\n"
     ]
    }
   ],
   "source": [
    "from langchain_text_splitters.character import CharacterTextSplitter\n",
    "text_splitter = CharacterTextSplitter(chunk_size=500, chunk_overlap=0, separator='.')\n",
    "\n",
    "split_docs = []\n",
    "for doc in docs:split_docs.extend(text_splitter.split_text(doc))\n",
    "for i in range(len(split_docs)): print(f'第{i}个doc长度为：{len(split_docs[i])}', end='\\n-----------\\n')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "## 三、分块方法评估\n",
    "每种分块方法都有其使用的场景，在文本分块时最好验证多种方法在当前场景下的性能，我们先对南瓜书进行不同长度递归字符分块得到其召回率，再测试多种分块方法得到以下结果：\n",
    "\n",
    "\n",
    "|分块长度\\召回率 | top_1 | top_3 | top_5 | top_10 |\n",
    "| --- | --- | --- | --- | --- |\n",
    "|200|0.65|0.78|0.81|0.85|\n",
    "|300|0.63|0.70|0.76|0.84|\n",
    "|400|0.68|0.71|0.77|0.83|\n",
    "|500|0.65|0.67|0.75|0.79|\n",
    "\n",
    "通过上表可以看出召回率大体随分块长度的增加而减少，可能是随着长度的增加文本中夹杂的噪音增多，影响了向量模型的准确性，再者开源向量模型训练集中单句占比较多，模型可能并不擅长从多个句子中提取信息。接着我们又在chunk_size为200下测试了定长分块、递归字符分块、语义+递归字符分块，结果如下：\n",
    "\n",
    "|分块方式\\召回率 | top_1 | top_3 | top_5 | top_10 |\n",
    "| --- | --- | --- | --- | --- |\n",
    "|定长分块|0.55|0.73|0.79|0.82|\n",
    "|递归字符分块|0.63|0.69|0.76|0.83|\n",
    "|语义+递归字符分块|0.62|0.71|0.75|0.80|\n",
    "\n",
    "综合来讲递归字符分块整体上好于其他分块方法，证明它能更好地捕捉文本的语义结构；但定长分块在前三与前五的召回率都高于递归字符分块，不同的数据集对应的最佳分块方法也不同，恰好定长分块在南瓜书上的表现不错；语义+递归字符分块的表现略差于递归字符分块，可以看出在南瓜书中语义分析并没有太大帮助，相反起到了反作用。因此在实际应用中,我们需要尝试不同的分块长度和分块方法,并评估它们的性能,找到最合适的方案。\n",
    "\n",
    "> 若搜索结果所在页数与问题所在页数相同则召回成功，不同则召回失败，将召回成功次数除以总次数得到召回率。即$R=\\frac{A}{N}$其中$N$为总的召回次数，$A$为召回成功次数，$R$为召回率。"
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "Python 3 (ipykernel)",
   "language": "python",
   "name": "python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.12.4"
  }
 },
 "nbformat": 4,
 "nbformat_minor": 4
}
